Skip to content

feat(libecalc): add closed-form liquid pump process simulation - #1681

Merged
kjbrak merged 1 commit into
mainfrom
feat/liquid-pump-process-simulation
Jul 23, 2026
Merged

feat(libecalc): add closed-form liquid pump process simulation#1681
kjbrak merged 1 commit into
mainfrom
feat/liquid-pump-process-simulation

Conversation

@kjbrak

@kjbrak kjbrak commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What is this PR all about?

Adds a closed-form liquid pump as its own process graph in the new (experimental) process domain, under a dedicated PUMP_PROCESS_SIMULATIONS YAML keyword, kept separate from the gas pipeline.

What is added

  • Pump (libecalc/process/pump/) is fully closed-form. Its result PumpEvaluationResult carries efficiency, specific shaft work, shaft power, speed, feasibility, the operating rate/head and the recirculation rate. Minimum-flow recirculation and minimum-head choking are computed directly - no solver.

  • PumpProcessSimulation builds a fixed liquid process graph and evaluates a sequence of data-defined inputs (the domain is time-agnostic - it carries no Period):

    inlet -> recirc mixer -> pump -> recirc splitter -> choke -> outlet
                  ^                        |
                  +--------- recycle ------+
    

    It exposes typed LiquidProcessUnit nodes (entities with ids), serial ProcessUnitConnection edges (reusing the shared process primitive), and a LiquidRecirculationLoop (its own id, splitter id + mixer id). evaluate(...) returns, for each input in order, the flat pump result plus a LiquidStream on every connection, keyed by connection id.

  • New YAML PUMP_PROCESS_SIMULATIONS: - TYPE: PUMP (with PUMP_MODEL, INLET, REQUIRED_DISCHARGE_PRESSURE), its mapper, and YamlModel.get_pump_process_simulations().

How a consumer uses it

from libecalc.process.pump.pump import Pump
from libecalc.process.pump.liquid_stream import LiquidStream
from libecalc.process.pump.pump_process_simulation import (
    PumpProcessSimulation,
    PumpOperatingInput,
)

simulation = PumpProcessSimulation(
    pump=Pump(pump_chart=chart_data, minimum_flow_rate_m3_per_hour=277.0),
    name="water_injection",
)

results = simulation.evaluate(
    [
        PumpOperatingInput(
            inlet_stream=LiquidStream.from_volumetric_rate(
                volumetric_rate_m3_per_day=24_000.0,
                pressure_bara=10.0,
                density_kg_per_m3=1010.0,
            ),
            required_discharge_pressure_bara=200.0,
        ),
    ]
)
for result in results:
    result.pump_result.shaft_power_mw   # operating point: power, efficiency, head, speed
    result.connection_streams           # {connection_id: LiquidStream} for a graph diagram

The graph structure (stable for persistence and visualisation) is read separately:

simulation.get_process_units()             # typed nodes with ids (inlet, mixer, pump, splitter, choke, outlet)
simulation.get_process_unit_connections()  # edges: (id, from_unit_id, to_unit_id)
simulation.get_recirculation_loop()        # (id, splitter_id, mixer_id)

Capabilities and patterns supported

  • Stable, libecalc-owned ids on every unit and connection.
  • Typed units aligned with a generic process-unit taxonomy (inlet, direct mixer, pump, direct splitter, choke, outlet).
  • Intermediate LiquidStream on every connection, keyed by connection id.
  • A recirculation-loop descriptor (splitter + mixer) mirroring how the compressor models minimum-flow recycle.
  • Time-agnostic evaluation behind a single stable entry point; the period/time mapping is kept by the caller (the YAML mapper), not the domain.
  • Closed-form evaluation (no solver, no root-finding, no repeated stream re-runs).

Intended integration in a consuming application

A consuming application (for example a persistence-and-worker backend that renders process diagrams) can treat the pump like any other process graph, because libecalc owns the graph and its ids. The application only maps and schedules; it never re-implements pump physics or topology:

# `repo` is the consuming application's own persistence layer; not part of libecalc.
# The mapper returns the simulation, the ordered data-defined inputs, and the matching periods.
simulation, operating_inputs, periods = yaml_model.get_pump_process_simulations()[0]

# 1. Persist the graph once (structure and ids come from libecalc)
for unit in simulation.get_process_units():
    repo.save_unit(id=unit.get_id(), unit_type=unit.unit_type, name=unit.name)
for connection in simulation.get_process_unit_connections():
    repo.save_connection(
        id=connection.get_id(),
        from_unit_id=connection.get_from_process_unit_id(),
        to_unit_id=connection.get_to_process_unit_id(),
    )
repo.save_recirculation_loop(simulation.get_recirculation_loop())

# 2. A worker evaluates the inputs and stores the results, re-attaching the period by index
results = simulation.evaluate(operating_inputs)
for period, result in zip(periods, results, strict=True):
    for connection_id, stream in result.connection_streams.items():
        repo.save_stream(connection_id=connection_id, period=period, stream=stream)
    repo.save_operating_point(period=period, pump_result=result.pump_result)  # shaft power, efficiency, head, speed

Note: unit and connection ids are generated when the simulation is built. A consumer that needs ids to be stable across runs persists them once and reuses them, exactly as the compressor process graph is handled.

Why it is shaped this way, and how it differs from the compressor

The pump deliberately reuses the compressor process graph contract (typed units, connections, per-connection streams, a recirculation loop, all with ids) so a consumer can persist and visualise pumps and compressors with one pattern. It deliberately does not reuse the compressor runtime:

  • The compressor needs a solver (shaft speed, anti-surge and pressure control are root-finding problems) and obtains intermediate streams by re-running the pipeline to each unit. The pump is closed-form: required head follows from the pressure rise and density, recirculation clamps the rate to the minimum flow, and choking clamps the head to the curve - all in one pass. Importing the solver and runner machinery would add complexity with no benefit.
  • The compressor pipeline is built around a compressible FluidStream with composition, EoS and temperature. A liquid is modelled as an incompressible LiquidStream (pressure, density, mass rate). It cannot flow through the gas pipeline types, and the persisted gas stream shape carries gas-only fields. Liquid is therefore kept as a separate track rather than forced through the gas hierarchy or changing working compressor code.

There is no planned support for generic, user-composable liquid process systems - only this pump process and, soon, a pump-system process (TYPE: PUMP_SYSTEM) with pumps in parallel. If a composable liquid system is ever needed, the internal stream projection can be replaced by a real per-unit liquid runner behind the same public evaluate(...) contract, without callers changing. The LiquidStream and LiquidStreamPropagator abstractions already exist to support that.

What else did you consider?

  • Return only a flat pump result. Rejected: a flat result cannot populate a process graph, so it cannot support the diagram and per-connection streams a consumer wants.
  • Fuse the pump into the gas FluidStream pipeline. Rejected: the pipeline is gas-typed and solver-driven; this would change working compressor infrastructure for no pump benefit.
  • A full executable liquid process system with its own runner and configuration handlers. Deferred: unnecessary for a fixed, closed-form topology. The LiquidStreamPropagator interface keeps that door open.

Between the lines?

  • The new process domain (exposed through the PUMP_PROCESS_SIMULATIONS YAML keyword) is experimental and not yet wired to energy output or a consumer; this PR is a contract-first building block validated by tests (unit propagation, graph structure, streams-per-connection, order-preserving evaluation, and end-to-end YAML mapping).
  • Zero-rate periods are treated as the pump being off: they still produce a full result (zero power, outlet pressure equal to the inlet pressure, zero-flow streams). Suction pressure and density are required to be positive in every period (they describe the physical inlet fluid); the required discharge pressure is only required when the pump runs, and is relaxed for off periods.
  • HEAD_MARGIN is not supported in the new pump process domain - a point above the chart's maximum head is flagged as infeasible (ABOVE_MAXIMUM_HEAD_AT_RATE) rather than snapped to the maximum, so a facility pump chart that sets a non-zero HEAD_MARGIN is rejected.
  • No behaviour change for existing pump (energy) consumers or for the compressor domain.
  • Legacy installation pump YAML (ENERGY_USAGE_MODEL: TYPE: PUMP) is untouched.

Refs.:

Refs: equinor/ecalc-internal#2059

@kjbrak
kjbrak requested a review from a team as a code owner July 21, 2026 13:48
@kjbrak
kjbrak force-pushed the feat/liquid-pump-process-simulation branch 2 times, most recently from 166f490 to 2a52ea5 Compare July 21, 2026 14:23
Comment thread src/libecalc/presentation/yaml/mappers/pump_process_simulation_mapper.py Outdated
@kjbrak
kjbrak force-pushed the feat/liquid-pump-process-simulation branch from 2a52ea5 to c65f2cf Compare July 22, 2026 07:48
Comment thread src/libecalc/presentation/yaml/mappers/pump_process_simulation_mapper.py Outdated
Comment thread src/libecalc/presentation/yaml/model.py Outdated
Comment thread src/libecalc/process/pump/pump.py
Comment thread src/libecalc/process/pump/pump_process_simulation.py Outdated
Comment thread src/libecalc/process/pump/pump_process_simulation.py Outdated
Comment thread src/libecalc/process/pump/pump_process_simulation.py Outdated
Comment thread src/libecalc/process/pump/pump_process_simulation.py
simple_yaml.main_file.read()
+ """

PROCESS_SIMULATIONS:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice. can you add an example yaml in web in order for the rest of the team to have access to and use?

@tj098895 tj098895 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, please see my comments.

There are many simplifictions I want to make, both here and in existing code, but worth to fix soon instead.

Also, I see that it is hard to know what is the boundary of what is going on in core vs backend, and I want to clarify. We can talk later again :)

@kjbrak
kjbrak force-pushed the feat/liquid-pump-process-simulation branch 4 times, most recently from e8c551c to 1e1aa7c Compare July 23, 2026 09:55
Introduce a liquid pump process graph under a dedicated PUMP_PROCESS_SIMULATIONS
YAML keyword, kept separate from the gas FluidStream pipeline.

- Extend the closed-form Pump result with efficiency, specific shaft work,
  shaft power and speed at the operating point (PumpEvaluationResult).
- Add PumpProcessSimulation: a fixed liquid graph of inlet, recirculation
  mixer, pump, recirculation splitter, choke and outlet with typed
  LiquidProcessUnit entities, connections and a recirculation loop. The domain
  is time-agnostic: evaluate() maps a sequence of PumpOperatingInput to a
  liquid stream on every connection plus the pump result, in order.
- Add the PUMP_PROCESS_SIMULATIONS "TYPE: PUMP" YAML root, its mapper and
  YamlModel.get_pump_process_simulations().

Refs: equinor/ecalc-internal#2059
@kjbrak
kjbrak force-pushed the feat/liquid-pump-process-simulation branch from 1e1aa7c to 414f957 Compare July 23, 2026 11:07
@kjbrak
kjbrak merged commit 1b37ab0 into main Jul 23, 2026
25 checks passed
@kjbrak
kjbrak deleted the feat/liquid-pump-process-simulation branch July 23, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants